Skip to content

feat: agent-lifetime heartbeat sender#32

Merged
PabloPardoGarcia merged 2 commits into
mainfrom
pablo/gla2-140-heartbeat-sender
Jul 20, 2026
Merged

feat: agent-lifetime heartbeat sender#32
PabloPardoGarcia merged 2 commits into
mainfrom
pablo/gla2-140-heartbeat-sender

Conversation

@PabloPardoGarcia

Copy link
Copy Markdown
Member

Adds the agent-lifetime heartbeat: a daemon thread pinging POST /v1/heartbeat from init() until process exit, so the platform can tell a live-but-idle agent from a vanished one — the question traces structurally cannot answer (spans export only on finish).

Design (per the heartbeat spec, payload v1)

  • Payload: instance_id (UUID per process lifetime), agent_name (defaults to service_name), sent_at, sdk_language/sdk_version, open_traces (open ROOT trace ids, capped 32) + open_trace_count — lets the backend derive running vs ready from the payload alone. Workspace is never in the payload (backend derives it from the API key).
  • Emission: immediate first ping, then every heartbeat_interval (default 15s, clamped [5,300] — staleness is derived from the interval, so the bounds are wire contract). client.shutdown() / atexit send a final stopped: true ping. No signal handlers — a library must not own process signals; silence after an unhandled SIGTERM/SIGKILL is what the backend's stale→gone path covers.
  • Failure: never raises into user code; 3s timeout, no retries/queueing (a late heartbeat is misinformation); one warning per process, then debug.
  • Fork: child re-arms with a fresh instance_id.
  • Off by default (heartbeat=True / GLASSFLOW_HEARTBEAT) until the ingest path ships; flipping the default is a coordinated later change.

Testing

TDD; 24 new tests including a real subprocess proving the atexit stopped ping and a real local HTTP server exercising the default transport (auth header, 204). heartbeat_transport is injectable like span_exporter — no mocking.

Also sweeps internal ticket ids out of code comments (public repo).

A daemon thread pings POST /v1/heartbeat from init() until process exit,
answering the one question traces cannot: is this agent process alive
right now? Spans export only when they finish, so an idle or crashed
agent is otherwise indistinguishable from a healthy quiet one.

Payload v1: instance_id (UUID per process lifetime), agent_name
(defaults to service_name so the agents and traces views agree),
sent_at, sdk_language/version, and the currently-open ROOT trace ids
(capped at 32, with the true count) — this is what lets the backend
derive running vs ready from the payload alone. An OpenRootSpanTracker
span processor maintains the open set.

Emission: first ping immediately at init (the agent appears without
waiting an interval), then every heartbeat_interval seconds (default
15, clamped [5,300] — the backend derives staleness from the interval,
so the bounds are wire contract). Graceful shutdown (client.shutdown()
or atexit) sends a final stopped:true ping; no signal handlers are
installed on purpose — a library must not own process signals, and
silence after SIGKILL is exactly what the backend's stale->gone path
exists for. Forked children re-arm with a fresh instance_id.

Never raises into user code: 3s timeout, no retries or queueing (a
late heartbeat is misinformation), one warning per process then debug.

Off by default this release (heartbeat=True / GLASSFLOW_HEARTBEAT):
the ingest path ships separately; flipping the default is a
coordinated later change. heartbeat_transport is injectable for tests,
like span_exporter; includes a real-subprocess atexit test and a real
HTTP server test.

Also sweeps internal ticket ids out of code comments (public repo).
Security/robustness review findings on the heartbeat:

- The final stopped ping runs inside atexit: against a dead or hanging
  endpoint it could hold the user's process exit for up to ~6s (3s
  in-flight join + 3s final ping). The final ping now gets a 1s budget
  (a missed stopped ping just reports gone instead of stopped), and
  both timeouts are constructor-injectable; a hanging-socket test pins
  the exit-latency bound.
- os.register_at_fork callbacks can never be unregistered, so the
  per-sender registration leaked a callback + sender reference for
  every init() in long-lived apps. One module-level fork hook now
  dispatches to a WeakSet registry; stopped senders leave it, and
  atexit.unregister releases the shutdown hook reference too.
- Documented that TLS verification is urllib's default and deliberately
  not configurable in the transport.

Reviewed and clean without changes: no secrets or API key in logs or
error paths, no hostname/PID in the payload (privacy by design),
payload size bounded by the 32-entry open_traces cap, uuid4 (random,
no MAC derivation) for instance identity.
@PabloPardoGarcia
PabloPardoGarcia requested a review from Nuzhnov July 20, 2026 07:55
@PabloPardoGarcia
PabloPardoGarcia merged commit 7c592a8 into main Jul 20, 2026
7 checks passed
@PabloPardoGarcia
PabloPardoGarcia deleted the pablo/gla2-140-heartbeat-sender branch July 23, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants